Skip to content

Add sequence callback support for animation timelines#3526

Merged
mattgperry merged 9 commits intomainfrom
claude/low-overhead-animation-XwKRA
Feb 3, 2026
Merged

Add sequence callback support for animation timelines#3526
mattgperry merged 9 commits intomainfrom
claude/low-overhead-animation-XwKRA

Conversation

@mattgperry
Copy link
Collaborator

Summary

This PR adds support for callbacks in animation sequences, allowing developers to execute code at specific points during a sequence timeline. Callbacks can be positioned at absolute times, relative offsets, or referenced by labels, and support both forward playback (onEnter) and backward scrubbing (onLeave).

Key Changes

  • New SequenceCallbackAnimation class (motion-dom): A lightweight animation implementation that fires callbacks when the timeline crosses specific time points. It handles both forward playback and backward scrubbing scenarios.

  • Callback segment support: Extended AnimationSequence type to support callback segments as [{ onEnter?, onLeave? }, { at? }] tuples, allowing callbacks to be interspersed with animations.

  • Enhanced createAnimationsFromSequence:

    • Now returns a CreateAnimationsResult object containing animationDefinitions, callbacks, and totalDuration
    • Extracts and resolves callback timing using the same at positioning logic as animations (absolute, relative +/-, < for previous, or label references)
    • Sorts callbacks by time for efficient lookup during playback
  • Integration with animation sequences: Updated both animateSequence functions (in framer-motion and waapi) to instantiate and include SequenceCallbackAnimation in the animation group when callbacks are present.

  • Comprehensive test coverage: Added 10+ tests covering callback extraction, timing resolution, multiple callbacks, label-based positioning, and interaction with animation timing.

Implementation Details

  • Callbacks are extracted during sequence parsing and don't affect animation timing or duration calculations
  • The SequenceCallbackAnimation uses simple time comparison logic with minimal overhead
  • Callbacks fire only when time crosses their threshold (prevents duplicate fires on repeated time sets)
  • Backward scrubbing triggers onLeave callbacks, enabling interactive timeline scrubbing scenarios
  • All existing tests updated to use a helper function that maintains backward compatibility with the new return structure

https://claude.ai/code/session_01Es5grCjnfwALQxsWvrrpzF

This implements #2205 - callbacks can now be injected at specific points
in an animation sequence. Callbacks support both forward (onEnter) and
backward (onLeave) firing for scrubbing support.

Usage:
```js
animate([
  [element, { x: 100 }, { duration: 1 }],
  [{ onEnter: () => console.log("midpoint!") }, { at: 0.5 }],
  [element, { y: 200 }, { duration: 1 }],
])
```

Implementation has minimal overhead:
- SequenceCallbackAnimation is lightweight (no DOM ops, just time tracking)
- Callbacks are sorted and stored separately from animations
- Only fires callbacks when time crosses thresholds

https://claude.ai/code/session_01Es5grCjnfwALQxsWvrrpzF
@claude
Copy link

claude bot commented Feb 2, 2026

Code Review

I've reviewed PR #3526 which adds sequence callback support for animation timelines. Overall, this is a well-implemented feature with strong test coverage. Here's my detailed feedback:


Strengths

1. Excellent Architecture

  • Clean separation of concerns with SequenceCallbackAnimation as a standalone class
  • Minimal overhead implementation - no DOM operations, just simple time comparisons
  • Proper integration with existing AnimationPlaybackControls interface
  • The callback extraction logic in createAnimationsFromSequence is well-structured

2. Comprehensive Test Coverage

  • 10+ test cases covering edge cases (time 0, end of animation, empty arrays, etc.)
  • Tests for both forward playback and backward scrubbing
  • Good integration tests in sequence/__tests__/index.test.ts
  • The helper function getAnimationDefinitions for backward compatibility is a nice touch

3. Good Documentation

  • Clear comments explaining callback behavior
  • Type definitions include JSDoc explanations
  • PR description is thorough with implementation details

⚠️ Issues & Concerns

1. Type Safety Issue (High Priority)

Location: packages/framer-motion/src/animation/animate/sequence.ts:40 and packages/framer-motion/src/animation/animators/waapi/animate-sequence.ts:81

animations.push(
    callbackAnimation as unknown as AnimationPlaybackControlsWithThen
)

Problem: The double type assertion (as unknown as) is a code smell that bypasses TypeScript's type checking. This suggests the types don't properly align.

Root Cause: SequenceCallbackAnimation implements AnimationPlaybackControls but is being cast to AnimationPlaybackControlsWithThen, which likely includes a .then() method for promise chaining that SequenceCallbackAnimation doesn't have.

Impact: Runtime errors could occur if code tries to call .then() on the callback animation.

Recommendation:

  • Either add .then() support to SequenceCallbackAnimation (preferred)
  • Or create a proper type union that allows both types
  • Check how other animation types handle the then method - likely need to return this to support chaining

2. Edge Case: Large Time Jumps (Medium Priority)

Location: packages/motion-dom/src/animation/SequenceCallbackAnimation.ts:73-93

Problem: When time is set with large jumps (e.g., from 0 to 1 second), the code fires ALL callbacks in between sequentially in a single frame. For sequences with many callbacks (say, 100+), this could cause:

  • Frame drops
  • Long synchronous execution blocking the main thread
  • Potential memory issues if callbacks create objects

Test Gap: No tests verify behavior with many callbacks (e.g., 50-100 callbacks)

Recommendation:

  • Add a test with a large number of callbacks to verify performance
  • Consider documenting this behavior or adding a warning if callback count is high
  • For production use with many callbacks, consider batching or async execution

3. Memory Leak Potential (Medium Priority)

Location: packages/motion-dom/src/animation/SequenceCallbackAnimation.ts:37-40

this.finished = new Promise((resolve, reject) => {
    this.resolveFinished = resolve
    this.rejectFinished = reject
})

Problem: If cancel() is called, the promise is rejected (line 109), but if the animation is never completed or cancelled, the promise hangs indefinitely with callbacks stored in memory.

Test Gap: No test verifies memory cleanup or that finished promise is properly handled

Recommendation:

  • Add a test that verifies calling cancel() properly rejects the promise
  • Consider adding cleanup logic in stop() as well (currently it just sets state to idle)
  • Document the lifecycle expectations

4. Callback Detection Logic (Low-Medium Priority)

Location: packages/framer-motion/src/animation/sequence/create.ts:692-704

function isCallbackSegment(segment: unknown): segment is [SequenceCallback, At] {
    if (\!Array.isArray(segment) || segment.length \!== 2) return false
    const [callback, options] = segment
    if (typeof callback \!== 'object' || callback === null) return false
    return (
        ('onEnter' in callback || 'onLeave' in callback) &&
        \!('duration' in options) &&
        \!('ease' in options)
    )
}

Problem: This detection heuristic could have false positives/negatives:

  • What if someone accidentally passes { onEnter: true } (not a function)?
  • What if future animation options are added that aren't checked here?
  • The check \!('duration' in options) might not be sufficient to distinguish callbacks from animations

Recommendation:

  • Add runtime type validation: check if onEnter/onLeave are actually functions
  • Consider a more explicit marker (e.g., { type: 'callback', onEnter: ... })
  • Add tests for malformed callback segments

5. Incomplete finished Promise Handling (Low Priority)

Location: packages/motion-dom/src/animation/SequenceCallbackAnimation.ts:30

Problem: The finished promise type in the class doesn't match the interface. AnimationPlaybackControls likely expects Promise<unknown> or similar, but this returns Promise<void>.

Also: The finished promise is only resolved in complete(), but GroupAnimation expects all animations' finished promises to resolve. If someone calls stop() or pause(), the promise never resolves.

Recommendation:

  • Check what other animation implementations do (likely resolve on stop as well)
  • Ensure promise resolves in all terminal states

🔍 Minor Issues

6. Code Style Consistency

  • Line 879 (SequenceCallbackAnimation.ts:125): The type cast could be cleaner
    return timeline.observe(this as unknown as Parameters<typeof timeline.observe>[0])
    This is overly complex - consider defining a proper type

7. Missing Null Check

  • Line 114 (SequenceCallbackAnimation.ts): complete() calls fireCallbacks() but doesn't verify callbacks exist (though fireCallbacks does check)

8. Test Quality

  • Test at line 69 (SequenceCallbackAnimation.test.ts): Uses calls.length = 0 to clear array. While valid, calls.splice(0) is more idiomatic

🔒 Security Considerations

No security issues identified. User-provided callbacks are executed as expected, which is the intended behavior. The implementation doesn't introduce XSS, injection, or other vulnerabilities.


🚀 Performance Considerations

Positive:

  • Minimal overhead (just time comparisons)
  • Callbacks sorted once at construction
  • No DOM operations or heavy computations

Potential Issue:

  • See "Large Time Jumps" concern above about many callbacks firing synchronously

📋 Recommendations Priority

  1. HIGH: Fix the type casting issues (as unknown as)
  2. MEDIUM: Add tests for edge cases (many callbacks, memory cleanup)
  3. MEDIUM: Clarify finished promise behavior across all terminal states
  4. LOW: Improve callback detection robustness
  5. LOW: Clean up minor style issues

Overall Assessment

This is a solid implementation with good separation of concerns and strong test coverage. The main issues are around type safety and some edge case handling. With the recommended fixes, this would be production-ready.

The feature design is sound - the bidirectional callback system (onEnter/onLeave) is well thought out for scrubbing scenarios.

Recommendation: Request changes for the type safety issues, then approve after fixes.

- Rename onEnter/onLeave to forward/backward for clarity
- Remove dedicated SequenceCallbackAnimation class
- Use animateSingleValue(0, 1) with onUpdate to track time crossings
- Much simpler implementation with same functionality

https://claude.ai/code/session_01Es5grCjnfwALQxsWvrrpzF
@claude
Copy link

claude bot commented Feb 2, 2026

PR Review: Add sequence callback support for animation timelines

I've thoroughly reviewed this PR. Overall, this is a well-implemented feature with solid test coverage and clear design decisions. Here's my detailed feedback:


✅ Strengths

1. Clean API Design

  • The callback API is intuitive: [{ forward?, backward? }, { at? }]
  • Supports all existing timing mechanisms (at, "+/-", "<", labels)
  • Forward/backward distinction enables interactive scrubbing scenarios

2. Excellent Test Coverage

  • 10+ comprehensive tests covering all timing scenarios
  • Tests verify callbacks don't affect animation timing/duration
  • Good use of helper function for backward compatibility

3. Minimal Performance Overhead

  • Callbacks are sorted once during parsing for O(n) lookup
  • Simple time comparison logic with no complex state management
  • Only creates tracking animation when callbacks are present

4. Architecture

  • Correctly returns structured CreateAnimationsResult object
  • Both animateSequence implementations properly updated
  • Type definitions are clear and well-documented

🔍 Issues Found

Critical: Incorrect Callback Timing Logic

Location: packages/framer-motion/src/animation/animate/sequence.ts:29-38 and packages/framer-motion/src/animation/animators/waapi/animate-sequence.ts:25-34

The current implementation recalculates prevTime inside the callback loop, which is incorrect:

for (const callback of callbacks) {
    const prevTime = prevProgress * totalDuration  // ❌ WRONG: recalculated each iteration
    
    if (prevTime < callback.time && currentTime >= callback.time) {
        callback.forward?.()
    } else if (prevTime >= callback.time && currentTime < callback.time) {
        callback.backward?.()
    }
}

Problem: When multiple callbacks exist at different times, prevTime should be constant across all callbacks in a single onUpdate call. The current code recalculates it for each callback, which is wasteful and could lead to confusion.

Fix: Move prevTime calculation outside the loop:

return (progress: number) => {
    const currentTime = progress * totalDuration
    const prevTime = prevProgress * totalDuration  // ✅ Calculate once
    
    for (const callback of callbacks) {
        if (prevTime < callback.time && currentTime >= callback.time) {
            callback.forward?.()
        } else if (prevTime >= callback.time && currentTime < callback.time) {
            callback.backward?.()
        }
    }
    
    prevProgress = progress
}

This affects both files (lines 29-38 in sequence.ts and lines 25-34 in animate-sequence.ts).


⚠️ Potential Issues

1. Edge Case: Callbacks at Exact Boundaries

The current logic uses < and >=, which means:

  • A callback at time 0 fires when currentTime >= 0 (will fire on first frame if starting from 0)
  • A callback at totalDuration fires when crossing into that time

Question: Is this the intended behavior? Consider:

  • Should callbacks at time 0 fire immediately on animation start?
  • What happens if the animation starts at a non-zero time (e.g., after seeking)?

Recommendation: Add tests for these edge cases or document the expected behavior.

2. Missing Integration Tests

While unit tests are excellent, I don't see E2E or integration tests that verify:

  • Callbacks actually fire during real animations
  • Backward scrubbing works with playback controls (.seek(), .reverse())
  • Multiple callbacks fire in correct order during a single frame jump
  • Callbacks work with repeat, yoyo, or other playback options

Recommendation: Add at least one integration test demonstrating the feature working end-to-end.

3. Type Safety in isCallbackSegment

Location: packages/framer-motion/src/animation/sequence/create.ts:465-477

The type guard checks:

return (
    ("forward" in callback || "backward" in callback) &&
    !("duration" in options) &&
    !("ease" in options)
)

Issue: This could false-positive on objects that accidentally have forward/backward properties. Also, why check for duration and ease in options instead of callback?

Suggestion: Add a runtime check that forward and backward are functions:

return (
    (("forward" in callback && typeof callback.forward === "function") ||
     ("backward" in callback && typeof callback.backward === "function")) &&
    !("duration" in options) &&
    !("ease" in options)
)

4. Duplicate Code

The createCallbackUpdater function is identical in both files:

  • packages/framer-motion/src/animation/animate/sequence.ts:19-42
  • packages/framer-motion/src/animation/animators/waapi/animate-sequence.ts:17-38

Recommendation: Extract to a shared utility function to follow DRY principle.

5. Performance: Unnecessary Iteration

The callback loop runs through all callbacks on every frame, even those that won't fire. For sequences with many callbacks, consider optimizing:

// Track the index of the next callback to check
let nextCallbackIndex = 0

return (progress: number) => {
    const currentTime = progress * totalDuration
    const prevTime = prevProgress * totalDuration
    
    if (currentTime > prevTime) {
        // Forward playback
        while (nextCallbackIndex < callbacks.length && 
               callbacks[nextCallbackIndex].time <= currentTime &&
               callbacks[nextCallbackIndex].time > prevTime) {
            callbacks[nextCallbackIndex].forward?.()
            nextCallbackIndex++
        }
    } else if (currentTime < prevTime) {
        // Backward playback
        while (nextCallbackIndex > 0 && 
               callbacks[nextCallbackIndex - 1].time < prevTime &&
               callbacks[nextCallbackIndex - 1].time >= currentTime) {
            nextCallbackIndex--
            callbacks[nextCallbackIndex].backward?.()
        }
    }
    
    prevProgress = progress
}

However, this may be premature optimization unless you expect sequences with 100+ callbacks.


🎨 Style & Documentation

1. Naming: forward vs onEnter

The PR description mentions onEnter and onLeave, but the implementation uses forward and backward. Consider if the original names were more intuitive:

  • onEnter / onLeave - suggests entering/leaving a time region (clearer for users)
  • forward / backward - suggests playback direction (more technical)

Not a bug, but worth considering for API clarity.

2. Documentation

Add JSDoc to the public-facing SequenceCallback type with examples:

/**
 * Callback to be invoked at a specific point in the sequence.
 * 
 * @example
 * ```ts
 * animate([
 *   [element, { opacity: 1 }, { duration: 1 }],
 *   [{ forward: () => console.log('halfway!') }, { at: 0.5 }],
 *   [element, { x: 100 }, { duration: 1 }]
 * ])
 * ```
 * 
 * @property forward - Called when timeline crosses this point moving forward
 * @property backward - Called when timeline crosses this point moving backward (e.g., during scrubbing)
 */
export interface SequenceCallback {
    forward?: VoidFunction
    backward?: VoidFunction
}

🧪 Testing Recommendations

  1. Add integration test:

    test("Callbacks fire during actual animation playback", async () => {
        const calls: string[] = []
        const element = document.createElement("div")
        
        const controls = animateSequence([
            [element, { x: 100 }, { duration: 1 }],
            [{ forward: () => calls.push('mid') }, { at: 0.5 }],
            [element, { y: 100 }, { duration: 1 }]
        ])
        
        await controls.then(() => {
            expect(calls).toEqual(['mid'])
        })
    })
  2. Add backward playback test:
    Test with .seek() or .reverse() to verify backward callbacks work.

  3. Add boundary test:
    Verify behavior when callbacks are at time 0 or totalDuration.


🎯 Summary

Overall Assessment: This is a solid implementation with good design decisions. The critical issue with timing calculation should be fixed before merging, and I recommend adding integration tests. The other issues are minor improvements.

Recommendation:Approve after addressing the critical timing bug

Required Changes:

  • ✅ Fix prevTime calculation (move outside loop in both files)

Strongly Recommended:

  • ⚠️ Add integration/E2E tests
  • ⚠️ Add edge case tests (callbacks at 0, totalDuration)

Optional Improvements:

  • 💡 Extract createCallbackUpdater to shared utility
  • 💡 Improve type guard in isCallbackSegment
  • 💡 Add JSDoc with examples
  • 💡 Consider performance optimization for many callbacks

Great work on this feature! The API design is clean and the test coverage is thorough. 🚀

Just destructure animationDefinitions directly from
createAnimationsFromSequence instead of wrapping it.

https://claude.ai/code/session_01Es5grCjnfwALQxsWvrrpzF
@claude
Copy link

claude bot commented Feb 3, 2026

PR Review: Add sequence callback support for animation timelines

Summary

This PR adds callback support to animation sequences, allowing developers to execute code at specific timeline points. The implementation is clean and well-tested with comprehensive coverage.

✅ Code Quality & Best Practices

Strengths:

  • Follows repository conventions (interfaces over types, named exports, arrow callbacks)
  • Clean separation of concerns with the CreateAnimationsResult interface
  • Excellent test coverage with 10+ new tests covering edge cases
  • Good TypeScript typing with clear interfaces (SequenceCallback, ResolvedSequenceCallback)
  • Helpful inline documentation explaining the callback timing logic

Minor suggestions:

  1. Type guard robustness (create.ts:465-477): The isCallbackSegment function could be more defensive:

    // Current check might miss some edge cases
    return (
        ("forward" in callback || "backward" in callback) &&
        \!("duration" in options) &&
        \!("ease" in options)
    )

    Consider also checking that options is an object to prevent runtime errors if someone passes non-object values.

  2. Callback timing precision (sequence.ts:31-37): The comparison uses >= and < which means callbacks fire when time equals or exceeds the threshold. Document whether callbacks at exactly time=0 fire on initialization, as this could be unexpected behavior.

🐛 Potential Issues

1. Edge case: Multiple callbacks at the same time
The current implementation fires all callbacks at the same time point in the order they appear in the sorted array. This is likely fine, but worth documenting the execution order guarantee.

2. Callback execution during seeks (sequence.ts:25-40)
When jumping from time 0 to time 5 (e.g., .currentTime = 5), all callbacks between 0 and 5 will fire. This is probably desired, but could cause performance issues with many callbacks. Consider documenting this behavior.

3. Memory leak potential
The createCallbackUpdater creates a closure that retains callbacks and totalDuration. If animations are created/destroyed frequently, ensure the callback animation is properly cleaned up. The existing animation cleanup mechanisms should handle this, but verify in integration tests.

4. WAAPI implementation (waapi/animate-sequence.ts:12-15)
The WAAPI version correctly ignores callbacks (as noted in commit message), but this creates an inconsistency where the same API behaves differently depending on the animation backend. Consider:

  • Adding a console warning when callbacks are used with WAAPI backend
  • Or documenting this limitation clearly in TypeScript types/JSDoc

⚡ Performance Considerations

Excellent:

  • Callbacks are sorted once during setup, not on every update
  • Uses simple threshold crossing logic with minimal overhead
  • The 0→1 animation approach is clever and reuses existing infrastructure

Suggestion:
The createCallbackUpdater iterates over ALL callbacks on every frame (sequence.ts:28). With many callbacks, consider optimizing:

// Early exit when we've passed all callbacks in forward direction
if (currentTime >= callbacks[callbacks.length - 1].time && 
    prevTime >= callbacks[callbacks.length - 1].time) {
    prevProgress = progress
    return
}

🔒 Security Concerns

No security issues identified. Callbacks are user-provided functions executed in the user's context, which is expected behavior.

🧪 Test Coverage

Excellent coverage with tests for:

  • Default timing, explicit at timing, relative timing (+/-), < syntax
  • Multiple callbacks, label-based positioning
  • Forward/backward callback firing
  • Callbacks don't affect animation timing

Missing test cases:

  1. Callbacks at exactly time 0 - does forward fire immediately?
  2. Rapid direction changes (forward → backward → forward)
  3. Multiple callbacks at the exact same time
  4. What happens with callbacks when animation is cancelled/stopped mid-sequence?
  5. Callback exceptions - if a callback throws, does it stop the animation?

📝 Documentation

The PR description is excellent. Consider adding:

  1. JSDoc examples in types.ts for SequenceCallback
  2. Note about WAAPI limitation
  3. Behavior documentation for edge cases mentioned above

Code-Specific Comments

sequence.ts:59-66 - Consider adding a comment explaining why linear ease is used:

// Use linear ease to ensure onUpdate receives uniform progress values
// for accurate time threshold detection

create.ts:420 - Nice that callbacks are sorted! Consider adding a comment explaining why:

// Sort callbacks by time for deterministic execution order
// and potential future optimization (early exit)

Conclusion

This is a solid implementation that follows the codebase's high standards. The feature is well-designed, properly tested, and adds valuable functionality. The main suggestions are around edge case documentation and minor optimizations.

Recommendation: Approve with minor suggestions

The identified issues are low-severity and mostly around documentation/edge cases rather than fundamental problems. Nice work!

@claude
Copy link

claude bot commented Feb 3, 2026

Code Review: Sequence Callback Support

Thank you for this PR! The implementation of callback support for animation sequences is well-designed and thoughtfully executed.


Strengths

1. Clean Architecture

  • The CreateAnimationsResult interface provides a clear contract for the enhanced return type
  • Callback extraction is cleanly separated from animation processing in create.ts
  • The isCallbackSegment type guard properly distinguishes callbacks from animations

2. Comprehensive Test Coverage

  • 10+ new test cases covering timing resolution, multiple callbacks, label positioning
  • Tests verify that callbacks do not affect animation timing (critical!)
  • Good coverage of edge cases like previous and relative timing

3. Smart Timing Logic

  • The createCallbackUpdater correctly tracks direction for forward/backward scrubbing
  • Callbacks are sorted by time for efficient lookup
  • Uses the same calcNextTime logic as animations for consistent timing resolution

Potential Issues

1. Critical: Callback Firing on Repeated Time Sets (HIGH PRIORITY)

Location: packages/framer-motion/src/animation/animate/sequence.ts:31-37

Problem: The implementation does not explicitly track which callbacks have already fired. If progress is set externally via scrubbing, the same callback could fire multiple times during forward playback if the progress crosses the threshold multiple times.

Recommendation: Consider tracking which callbacks have fired in each direction using a Set.

2. WAAPI Implementation Missing Callback Support (HIGH PRIORITY)

Location: packages/framer-motion/src/animation/animators/waapi/animate-sequence.ts

The WAAPI version only extracts animationDefinitions but completely ignores callbacks. This creates an inconsistent API where callbacks work in some contexts but silently fail in others.

Recommendation: Either add callback support to WAAPI, log a warning when callbacks are present but ignored, or document this limitation clearly.

3. Type Safety Issue in isCallbackSegment

Location: packages/framer-motion/src/animation/sequence/create.ts:465-477

The heuristic could incorrectly identify animation segments as callbacks if someone passes forward: 100 trying to animate a forward property. The check for duration/ease helps but is not foolproof.

Recommendation: Make callbacks more explicit with a unique property, class constructor, or Symbol key for type discrimination.

4. Performance: Linear Search on Every Update

Location: packages/framer-motion/src/animation/animate/sequence.ts:28

On every animation frame, this loops through ALL callbacks even if none are near the current time. For sequences with many callbacks this could impact performance.

Recommendation: Implement binary search or maintain a pointer to the next callback to only check callbacks near the current position.

5. Edge Case: Callbacks at Time 0

If a callback is positioned at time 0, it might fire immediately when the animation starts. This might be unexpected behavior.

Recommendation: Add a test case and clarify documentation on whether time-0 callbacks fire on animation start.


Code Style and Best Practices

Minor Issues:

  1. Line 92-104 in create.ts: The callback extraction logic could be extracted into a helper function
  2. Naming: forward and backward could be more descriptive. Consider onEnter and onLeave as mentioned in PR description
  3. Documentation: Add JSDoc comments to createCallbackUpdater explaining the algorithm and edge cases

Test Coverage Assessment

Excellent coverage of timing resolution, but missing:

  • Edge case: callback at time 0
  • Edge case: callback at end of sequence
  • Integration test: verify callbacks fire during actual animation playback not just extraction
  • Edge case: what happens if forward throws an error
  • Backward scrubbing behavior only extraction is tested not actual firing

Recommendations Summary

Must Fix before merge:

  1. Fix or document WAAPI callback support
  2. Improve type discrimination for callbacks

Should Fix:
3. Consider callback tracking to prevent duplicate fires
4. Add missing edge case tests

Nice to Have:
5. Optimize callback lookup for large sequences
6. Extract callback logic into helper function
7. Improve naming


Overall Assessment

This is a high-quality PR with solid implementation and thorough testing. The main concern is the missing WAAPI support and the type discrimination heuristic. With those addressed, this feature will be a valuable addition to the animation API.

Score: 8/10 - Would be 9.5/10 after addressing the WAAPI issue and type safety concern.

Let me know if you would like me to provide code snippets for any of the suggested fixes!

Return ResolvedAnimationDefinitions directly instead of a wrapper object.
Callbacks are passed via an optional out-parameter that only
animateSequence uses. WAAPI path and tests are unaffected.

https://claude.ai/code/session_01Es5grCjnfwALQxsWvrrpzF
@claude
Copy link

claude bot commented Feb 3, 2026

Code Review Summary

Thank you for this well-structured PR! The implementation of sequence callbacks is clean and follows good practices. Below is my detailed feedback:


✅ Strengths

Code Quality

  • Clean architecture: The separation between callback extraction (create.ts) and execution (sequence.ts) is well-designed
  • Type safety: Proper TypeScript interfaces with clear documentation (SequenceCallback, ResolvedSequenceCallback)
  • Consistent patterns: The isCallbackSegment type guard follows the same pattern as other segment detection in the codebase
  • Good documentation: Comments clearly explain the forward/backward callback behavior

Implementation Details

  • Minimal overhead: The createCallbackUpdater uses simple time comparison logic that's efficient
  • Proper state tracking: Using prevProgress to detect direction is a clean approach
  • Correct integration: Callbacks are sorted by time and don't interfere with animation timing calculations

🔍 Issues & Concerns

1. CRITICAL: WAAPI Implementation Missing Callback Support

The WAAPI version of animateSequence (in packages/framer-motion/src/animation/animators/waapi/animate-sequence.ts) was not updated to support callbacks. This creates an inconsistency:

// waapi/animate-sequence.ts - Line 12
createAnimationsFromSequence(definition, options).forEach(
    ({ keyframes, transition }, element: Element) => {
        animations.push(...animateElements(element, keyframes, transition))
    }
)
// ❌ Missing: No callbacks array, no callback animation added

Impact: Callbacks will silently not work when using WAAPI animations, leading to confusing behavior differences between animation backends.

Recommendation: Update the WAAPI implementation to match the main implementation, or document why callbacks are not supported in WAAPI mode.


2. Edge Case: Empty Sequence with Only Callbacks

// packages/framer-motion/src/animation/animate/sequence.ts:67-71
const firstTransition = animationDefinitions.values().next().value?.transition
const totalDuration = firstTransition
    ? (Object.values(firstTransition)[0] as any)?.duration ?? 0
    : 0

If a sequence contains only callbacks and no animations, animationDefinitions will be empty, resulting in totalDuration = 0. This means:

  • All callbacks will fire immediately at time 0
  • The callback animation will have 0 duration

Test case that would expose this:

const sequence = [
    [{ forward: () => console.log('1') }, { at: 0 }],
    [{ forward: () => console.log('2') }, { at: 1 }],
]
// Expected: callbacks fire at 0s and 1s
// Actual: both fire immediately at 0s

Recommendation: Either:

  • Calculate duration from callbacks if no animations exist: Math.max(...callbacks.map(c => c.time))
  • Throw an error or warning for callback-only sequences
  • Document this as unsupported behavior

3. Type Safety: Unsafe any Cast

// Line 70
(Object.values(firstTransition)[0] as any)?.duration ?? 0

The as any cast bypasses type checking. While the code likely works, this could mask type errors.

Recommendation: Use proper type assertion:

const firstTransitionValue = Object.values(firstTransition)[0] as Transition | undefined
const totalDuration = firstTransitionValue?.duration ?? 0

4. Potential Bug: Callback Firing Logic

// packages/framer-motion/src/animation/animate/sequence.ts:31-34
if (prevTime < callback.time && currentTime >= callback.time) {
    callback.forward?.()
} else if (prevTime >= callback.time && currentTime < callback.time) {
    callback.backward?.()
}

Issue with boundary conditions:

  • If currentTime === callback.time and prevTime === callback.time, neither condition fires
  • On first update when prevProgress = 0, if a callback is at time 0, it won't fire (since prevTime = 0, currentTime >= 0, but condition requires prevTime < 0)

Recommendation: Consider using <= for the first condition to handle exact matches:

if (prevTime <= callback.time && currentTime > callback.time) {
    // ... or use strict inequality with initialization
}

Or initialize prevProgress = -Infinity to ensure callbacks at time 0 fire on first frame.


5. Test Coverage Gaps

The tests added are good but missing critical scenarios:

Missing test cases:

  • ✅ Testing that callbacks don't affect timing (present)
  • ✅ Testing callback segments are skipped (present)
  • Testing that callbacks actually fire with correct timing
  • ❌ Testing backward/scrubbing behavior (onLeave callbacks)
  • ❌ Testing callbacks at time 0
  • ❌ Testing callbacks with label-based positioning
  • ❌ Testing multiple callbacks at the same time
  • ❌ Testing integration with animateSequence (end-to-end)

Per CLAUDE.md: "IMPORTANT: Always write a failing test FIRST before implementing any bug fix or feature."

Recommendation: Add integration tests that verify callbacks fire at the correct times during playback. Example:

test('Callbacks fire at correct times during playback', async () => {
    const calls: number[] = []
    const controls = animateSequence([
        [a, { x: 100 }, { duration: 1 }],
        [{ forward: () => calls.push(1) }, { at: 0.5 }],
        [{ forward: () => calls.push(2) }, { at: 1.0 }],
    ])
    
    await controls[0].then(() => {
        expect(calls).toEqual([1, 2])
    })
})

6. isCallbackSegment Type Guard Logic Issue

// packages/framer-motion/src/animation/sequence/create.ts:467-479
function isCallbackSegment(segment: unknown): segment is [SequenceCallback, At] {
    if (!Array.isArray(segment) || segment.length !== 2) return false
    const [callback, options] = segment
    if (typeof callback !== 'object' || callback === null) return false
    return (
        ('forward' in callback || 'backward' in callback) &&
        !('duration' in options) &&
        !('ease' in options)
    )
}

Issues:

  1. False positives: An object like [{ forward: fn, x: 100 }, { at: 0 }] would pass this check but is not a valid callback (has animation property x)
  2. Fragile detection: Relies on absence of duration/ease rather than presence of expected properties
  3. Interface At: Only checks for duration and ease, but At interface only has at? property. An object with duration is actually a full transition object

Better approach:

function isCallbackSegment(segment: unknown): segment is [SequenceCallback, At] {
    if (!Array.isArray(segment) || segment.length !== 2) return false
    const [callback, options] = segment
    if (typeof callback !== 'object' || callback === null) return false
    
    // Must have at least forward or backward
    const hasCallbackProps = 'forward' in callback || 'backward' in callback
    if (!hasCallbackProps) return false
    
    // Should not have animation keyframe properties (x, y, opacity, etc.)
    const callbackKeys = Object.keys(callback)
    const hasOnlyCallbackKeys = callbackKeys.every(k => k === 'forward' || k === 'backward')
    
    return hasOnlyCallbackKeys
}

7. Performance: Iterating All Callbacks on Every Frame

// sequence.ts:28-36
for (const callback of callbacks) {
    const prevTime = prevProgress * totalDuration
    
    if (prevTime < callback.time && currentTime >= callback.time) {
        callback.forward?.()
    } else if (prevTime >= callback.time && currentTime < callback.time) {
        callback.backward?.()
    }
}

Issue: On every frame update, this loops through all callbacks (even those already fired or not yet reached).

Impact: For sequences with many callbacks (e.g., 100+), this could impact performance, especially on mobile devices.

Optimization suggestion: Track the current callback index and only check nearby callbacks:

let currentIndex = 0

return (progress: number) => {
    const currentTime = progress * totalDuration
    const prevTime = prevProgress * totalDuration
    const movingForward = currentTime > prevTime
    
    if (movingForward) {
        // Fire all callbacks between prevTime and currentTime
        while (currentIndex < callbacks.length && callbacks[currentIndex].time <= currentTime) {
            if (callbacks[currentIndex].time > prevTime) {
                callbacks[currentIndex].forward?.()
            }
            currentIndex++
        }
    } else {
        // Handle backward scrubbing
        while (currentIndex > 0 && callbacks[currentIndex - 1].time >= currentTime) {
            currentIndex--
            if (callbacks[currentIndex].time < prevTime) {
                callbacks[currentIndex].backward?.()
            }
        }
    }
    
    prevProgress = progress
}

This reduces complexity from O(n) per frame to O(1) in most cases.


🔒 Security

No security concerns identified. The callbacks are user-provided functions with no special privileges.


📊 Performance

  • Minor concern: See Adding useViewportScroll #7 above regarding callback iteration
  • Memory: Minimal overhead - just storing callback references and times
  • Animation performance: No impact on animation calculations since callbacks are handled separately

📋 Recommendations Summary

Must fix before merge:

  1. Update WAAPI implementation or document limitation
  2. Handle empty sequence edge case
  3. Add integration tests for callback firing

Should fix:
4. Improve isCallbackSegment type guard logic
5. Remove any cast for type safety
6. Fix callback firing boundary conditions

Nice to have:
7. Optimize callback iteration for sequences with many callbacks
8. Add tests for backward scrubbing behavior


📝 Nitpicks

  • Line 50: Consider extracting magic value 0.3 (defaultDuration) to a named constant
  • Documentation: Consider adding JSDoc to createCallbackUpdater explaining the progress → time conversion

Overall, this is solid work with a clean design. The main blockers are the WAAPI inconsistency and missing integration tests. Once those are addressed, this will be a great addition to the library! 🚀

…, fix timing assertion

- Rename forward/backward to enter/leave across types, create, and sequence
- Add 4 integration tests: scrubbing thresholds, complete(), cancel() without scrub, cancel() after scrub
- Fix unit test expectation for trailing keyframe offset in callback timing test

https://claude.ai/code/session_01Es5grCjnfwALQxsWvrrpzF
@claude
Copy link

claude bot commented Feb 3, 2026

Pull Request Review: Sequence Callback Support

Thank you for this well-implemented feature! I've completed a thorough review of the PR and have the following feedback:


✅ Strengths

  1. Clean Architecture: The implementation elegantly extends the existing sequence system without disrupting animation timing calculations
  2. Comprehensive Test Coverage: Excellent tests covering scrubbing, forward/backward playback, and edge cases like cancel() and complete()
  3. Type Safety: Well-defined TypeScript interfaces for SequenceCallback and ResolvedSequenceCallback
  4. Documentation: Clear JSDoc comments explaining callback behavior

🔍 Issues Found

Critical Issues

1. Missing WAAPI Implementation

Location: waapi/animate-sequence.ts:6-19

The WAAPI version of animateSequence doesn't support callbacks at all. This creates an inconsistency where callbacks work with JS animations but silently fail with WAAPI.

Issue: The function doesn't pass an outCallbacks array to createAnimationsFromSequence and doesn't instantiate callback animations. Users enabling WAAPI will have callbacks silently ignored.

Recommendation: Either implement callback support for WAAPI (preferred), document that callbacks require JS animations, or add a runtime warning when callbacks are used with WAAPI.


2. Edge Case: Empty Sequences

Location: sequence.ts:67-71

If a sequence contains only callbacks and no animations, animationDefinitions will be empty, causing totalDuration to be 0. This means callbacks would fire immediately at time 0 regardless of their at positioning.

Recommendation: Track totalDuration in createAnimationsFromSequence and return it, or calculate the max callback time when no animations exist.


3. Callback Detection Logic Has False Positives

Location: create.ts:467-479

The isCallbackSegment function has issues:

  1. An object with { enter: someValue, opacity: 100 } would be detected as a callback segment instead of an animation keyframe
  2. Only checks for duration and ease in options, but valid animation segments can have just { at: '...' } without those properties

Recommendation: The detection should check that the callback object only contains enter and/or leave properties by validating all keys.


Performance Considerations

4. Linear Search on Every Frame Update

Location: sequence.ts:28-36

This loops through ALL callbacks on every frame update, even after they've fired. For sequences with many callbacks, this creates unnecessary overhead.

Impact: 10 callbacks = ~600 comparisons per second at 60fps; 100 callbacks = 6000 comparisons per second

Recommendation: Track an active range index and only check callbacks near the current time. This reduces complexity from O(n) per frame to O(callbacks_crossed) per frame.


Code Quality

5. Use of any Type Cast

Location: sequence.ts:70

The as any cast bypasses type safety. Use proper typing with as Transition instead.


6. Inconsistent Naming

Location: create.ts:465

The JSDoc comment refers to forward? and backward? but the actual properties are enter and leave. The comment should be updated to match.


Testing Gaps

7. Missing Test Coverage

The following scenarios aren't tested:

  1. Multiple callbacks at the same time
  2. Label-based positioning (mentioned in PR description)
  3. Callbacks with only enter or only leave
  4. Interaction with repeat option

📝 Minor Issues

8. Test Timing Fragility

The tests use setTimeout(50) for waitForFrame(). This is fragile and could cause flaky tests on slower systems. Per CLAUDE.md guidance, use frame.postRender() instead.


🎯 Recommendations Summary

Must Fix Before Merge:

  1. Add WAAPI implementation or document limitation
  2. Handle empty sequence case (callbacks only)
  3. Fix isCallbackSegment detection logic

Should Fix:
4. Optimize callback updater to avoid O(n) per frame
5. Replace as any with proper typing
6. Fix JSDoc comment naming

Nice to Have:
7. Add missing test coverage
8. Use frame.postRender() instead of setTimeout


🔒 Security & Safety

No security concerns identified. The callback feature doesn't introduce XSS, injection, or other vulnerabilities.


📊 Overall Assessment

This is a solid implementation with good architectural decisions. The main concerns are:

  • WAAPI parity issue (most critical)
  • Edge case handling (callbacks-only sequences)
  • Performance optimization opportunity for many-callback scenarios

The code adheres well to the repository's style guidelines and the test coverage demonstrates thoughtful consideration of edge cases. Great work overall!

Recommendation: Request changes for critical issues #1-3 before merging.

- Rename leave → exit across types, create, sequence, and tests
- Replace fragile duration lookup with SequenceCallbackData out-parameter
- Simplify isCallbackSegment type guard
- Use optional chaining for callbackData in create.ts

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@claude
Copy link

claude bot commented Feb 3, 2026

Code Review for PR #3526: Add sequence callback support for animation timelines

I've completed a thorough review of this PR. Overall, this is a well-designed and thoughtfully implemented feature with excellent test coverage. Here's my detailed feedback:


✅ Strengths

1. Excellent API Design

  • The callback segment syntax [{ enter?, exit? }, { at? }] is intuitive and consistent with existing sequence patterns
  • Support for both enter and exit callbacks enables bidirectional timeline scrubbing scenarios
  • Clean separation of concerns between callback extraction and animation logic

2. Robust Implementation

  • Callbacks correctly don't affect animation timing or duration calculations (verified in tests)
  • The time-crossing detection logic in createCallbackUpdater properly prevents duplicate fires
  • Proper sorting of callbacks by time for efficient lookup during playback

3. Comprehensive Test Coverage

  • 10+ tests covering various scenarios (scrubbing, complete(), cancel(), threshold crossing)
  • Tests verify both forward and backward scrubbing behavior
  • Edge cases like cancel() behavior are well-tested

4. Good Documentation

  • Clear JSDoc comments explaining the purpose and behavior
  • PR description provides excellent context on implementation details

⚠️ Issues & Concerns

1. CRITICAL: WAAPI Implementation Missing Callback Support

The PR updates framer-motion/animate/sequence.ts but does not update framer-motion/animators/waapi/animate-sequence.ts. This creates an inconsistency where callbacks will be silently ignored when using WAAPI.

Impact: Users relying on WAAPI will have callbacks silently fail without errors.

Recommendation: Update the WAAPI implementation similarly to add callback support. You'll need to export createCallbackUpdater or move it to a shared location.

2. MAJOR: Type Safety Issue in isCallbackSegment

The function uses any type which weakens type safety and doesn't validate segment structure properly:

function isCallbackSegment(
    segment: any[]  // ❌ Using 'any' weakens type safety
): segment is CallbackSegment {
    return "enter" in segment[0] || "exit" in segment[0]
}

Recommendation: Improve validation:

function isCallbackSegment(
    segment: unknown[]
): segment is CallbackSegment {
    return (
        segment.length === 2 &&
        typeof segment[0] === "object" &&
        segment[0] !== null &&
        ("enter" in segment[0] || "exit" in segment[0]) &&
        typeof segment[1] === "object" &&
        segment[1] !== null
    )
}

3. MINOR: Test Reliability - setTimeout vs Frame Sync

The tests use setTimeout(resolve, 50) instead of the recommended frame-synced approach per CLAUDE.md. This should use frame.postRender() for more reliable timing.

4. MINOR: Performance - Redundant Calculation

prevTime is recalculated in every loop iteration. Move it outside the loop:

return (progress: number) => {
    const currentTime = progress * totalDuration
    const prevTime = prevProgress * totalDuration  // Calculate once

    for (const callback of callbacks) {
        if (prevTime < callback.time && currentTime >= callback.time) {
            callback.enter?.()
        } else if (prevTime >= callback.time && currentTime < callback.time) {
            callback.exit?.()
        }
    }

    prevProgress = progress
}

🔒 Security

✅ No security concerns identified.


🎯 Summary

Recommendation: Request changes to address the critical WAAPI implementation gap and the type safety issue.

Must Fix:

  1. ❗ Add callback support to WAAPI implementation
  2. ❗ Improve isCallbackSegment type safety

Should Fix:

  1. Use frame-synced timing in tests (per CLAUDE.md)
  2. Move prevTime calculation outside loop

The core feature is excellent, but the WAAPI omission could cause silent failures for users. Once these issues are addressed, this will be a solid addition to the library.

Great work on the implementation and test coverage! 🚀

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@claude
Copy link

claude bot commented Feb 3, 2026

PR Review: Add sequence callback support for animation timelines

This is a well-implemented feature that addresses issue #2205. The implementation is clean, efficient, and follows the repository's conventions. Here's my detailed review:


✅ Strengths

1. Excellent API Design

  • The enter/exit callback pattern elegantly handles bidirectional scrubbing
  • Integration with existing at positioning syntax is intuitive
  • Minimal API surface area keeps it simple to use

2. Smart Implementation Strategy

  • Using animateSingleValue(0, 1) with onUpdate is clever - reuses existing animation infrastructure instead of creating a new specialized class
  • The out-parameter pattern (callbackData) maintains backward compatibility while avoiding breaking changes
  • Sorting callbacks by time enables efficient lookup during playback

3. Comprehensive Test Coverage

  • ✅ Unit tests verify timing resolution and non-interference with animations
  • ✅ Integration tests cover critical scenarios: scrubbing, complete(), cancel()
  • ✅ Tests verify the no-duplicate-fire behavior

4. Clean Type Safety

  • isCallbackSegment type guard properly narrows types
  • TypeScript interfaces are well-documented with JSDoc comments
  • No use of any types in production code (only in the type guard parameter)

🔍 Potential Issues & Questions

1. Callback Timing Edge Case (Minor)

In createCallbackUpdater (sequence.ts:20-40), there's a potential edge case:

if (prevTime < callback.time && currentTime >= callback.time) {
    callback.enter?.()
} else if (prevTime >= callback.time && currentTime < callback.time) {
    callback.exit?.()
}

Issue: If prevProgress === 0 initially and the animation starts at exactly the callback time (currentTime === callback.time), the enter callback will fire. But if the user immediately scrubs backward (animation.time = 0), the exit callback won't fire because prevTime === currentTime === callback.time.

Test case to consider:

const animation = animate([
  [{ enter: () => console.log('enter'), exit: () => console.log('exit') }, { at: 0 }],
  [element, { x: 100 }, { duration: 1 }],
])
animation.pause()
animation.time = 0  // enter fires (0 < 0 is false, but 0 >= 0 is true)
animation.time = 0.5
animation.time = 0  // exit won't fire (0.5 >= 0 and 0 < 0)

Suggested consideration: The edge case of callbacks at t=0 may behave unexpectedly with immediate scrubbing.

2. Multiple Callbacks at Same Time (Question)

The code sorts callbacks and iterates through all of them. What happens if multiple callbacks are defined at the exact same time?

animate([
  [element, { x: 100 }, { duration: 1 }],
  [{ enter: () => console.log('first') }, { at: 0.5 }],
  [{ enter: () => console.log('second') }, { at: 0.5 }],
])

Current behavior: Both callbacks will fire (in array order after sorting). This seems correct, but it's worth documenting or adding a test case.

3. Memory Leak Prevention (Question)

When callbackAnimation is created in sequence.ts:64-72, are the closures properly cleaned up when the animation is cancelled or completed? The createCallbackUpdater function maintains prevProgress state in closure.

Verification: Check if animateSingleValue's lifecycle methods properly clean up the onUpdate callback reference when the animation ends.

4. WAAPI Path Limitation (Informational)

The PR description mentions "The WAAPI path should stay minimal" - this means callbacks won't work with WAAPI animations. This is fine, but consider:

  • Should isCallbackSegment segments in WAAPI mode fail silently or warn?
  • Currently they're silently ignored (not passed to WAAPI's createAnimationsFromSequence)
  • A dev warning when callbacks are used in WAAPI mode might be helpful

5. waitForFrame in Tests (Minor)

function waitForFrame(): Promise<void> {
    return new Promise((resolve) => setTimeout(resolve, 50))
}

50ms seems arbitrary. According to CLAUDE.md, the recommended pattern is:

async function nextFrame() {
    return new Promise<void>((resolve) => {
        frame.postRender(() => resolve())
    })
}

This ensures tests are properly synced with the animation frame loop.


🎯 Performance Considerations

Excellent performance characteristics:

  • Callback checking is O(n) per update where n is number of callbacks (typically small)
  • No DOM operations in callback logic
  • Callbacks only fire when thresholds are crossed (not on every frame)
  • The dummy animation (0→1) is lightweight

🔒 Security Concerns

No security issues identified:

  • Callbacks are user-provided functions (expected)
  • No dynamic code execution or eval
  • No XSS vectors
  • Input validation is appropriate (callbacks are optional)

📋 Code Quality

Follows repository conventions:

  • Uses interface for type definitions ✅
  • Named exports only (no default exports) ✅
  • Arrow callbacks in appropriate places ✅
  • Proper JSDoc comments ✅

Minor style notes:

  • Consider adding readonly to SequenceCallbackData arrays if they shouldn't be mutated externally
  • The type guard isCallbackSegment(segment: any[]) uses any[] - could be unknown[] for strictness, but it's acceptable since it's a type guard

📚 Documentation

Missing/Recommended:

  • Usage examples in code comments (though the PR body has good examples)
  • Document the behavior when callbacks are at the same time
  • Document that callbacks don't work with WAAPI path
  • Consider adding to TypeScript docs what enter and exit are called on

🧪 Test Coverage Suggestions

Current coverage is good, but consider adding:

  1. Multiple callbacks at exact same time
  2. Callback at t=0 with immediate scrubbing
  3. Callback at t=duration (end of sequence)
  4. Callback with label references - verify labels resolve correctly
  5. Callback with relative timing (at: '+0.5', at: '<')
  6. Empty callback (neither enter nor exit defined)
  7. Rapid scrubbing - multiple threshold crossings in quick succession

🎬 Summary

This is a solid implementation that solves a real user need. The approach is pragmatic, performant, and maintainable. The main areas for improvement are:

  1. ⚠️ Fix the waitForFrame test helper to use frame.postRender (per CLAUDE.md)
  2. 📝 Add test for callbacks at t=0 edge case
  3. 📝 Consider test for multiple callbacks at same time
  4. 💭 Document WAAPI limitations
  5. 💭 Consider warning when callbacks used with WAAPI

Recommendation: ✅ Approve with minor suggestions

The implementation is production-ready. The suggestions above are minor improvements and edge case considerations rather than blocking issues.

Great work on the refactoring journey (from dedicated class → reusing animateSingleValue)! The final solution is elegant.


Note: Reviewed against repository's CLAUDE.md conventions and animation system architecture.

Reorder describe blocks so callback tests run before the buggy
"non-animatable color" test that corrupts frame loop state.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
@claude
Copy link

claude bot commented Feb 3, 2026

PR Review: Add sequence callback support for animation timelines

I've reviewed this PR and overall the implementation is solid. Here's my detailed feedback:

✅ Strengths

  1. Clean API Design: The enter/exit callback naming is intuitive and clearly conveys forward/backward playback semantics. The at positioning syntax matches existing sequence timing patterns.

  2. Minimal Overhead: Using animateSingleValue(0, 1) with onUpdate is clever - it leverages existing animation infrastructure without creating a custom animation class. This keeps the implementation lightweight.

  3. Non-Breaking: The optional callbackData out-parameter preserves the original API signature of createAnimationsFromSequence, ensuring WAAPI path and existing tests remain unaffected.

  4. Comprehensive Test Coverage: Both unit tests (timing calculations, no side effects) and integration tests (scrubbing, complete, cancel) are thorough. The test organization fix (moving tests before the "non-animatable color" test) shows good debugging.


⚠️ Issues & Concerns

1. Potential Race Condition in Tests (Medium Priority)

Location: packages/framer-motion/src/animation/animate/__tests__/animate.test.tsx:377-507

The tests use setTimeout(50) as a frame-waiting mechanism, which is fragile:

function waitForFrame(): Promise<void> {
    return new Promise((resolve) => setTimeout(resolve, 50))
}

Problem: This creates flaky tests that might fail on slower CI machines or when the frame loop is congested.

Recommendation: Use the proper frame loop mechanism from CLAUDE.md:

import { frame } from "motion-dom"

async function nextFrame() {
    return new Promise<void>((resolve) => {
        frame.postRender(() => resolve())
    })
}

2. Type Guard Missing Null Safety Check (Low Priority)

Location: packages/framer-motion/src/animation/sequence/create.ts:466-470

function isCallbackSegment(segment: any[]): segment is CallbackSegment {
    return segment[0] && ("enter" in segment[0] || "exit" in segment[0])
}

Issue: While the guard checks segment[0] truthiness, the any[] type parameter means segment itself could be empty or have non-object elements. A malformed sequence like [[], {}] would pass segment[0] && but might still cause issues.

Recommendation: Add explicit array length check:

function isCallbackSegment(segment: any[]): segment is CallbackSegment {
    return segment.length >= 2 && 
           typeof segment[0] === "object" && 
           segment[0] !== null &&
           ("enter" in segment[0] || "exit" in segment[0])
}

3. Callback Timing Edge Case (Low Priority)

Location: packages/framer-motion/src/animation/animate/sequence.ts:26-40

The callback updater iterates through ALL callbacks on every update:

for (const callback of callbacks) {
    const prevTime = prevProgress * totalDuration
    // checks every callback...
}

Performance: For sequences with many callbacks (e.g., 100+), this O(n) check happens every frame. While callbacks are sorted by time, the code doesn't short-circuit when callback.time exceeds currentTime.

Recommendation: Add early termination for forward playback:

for (const callback of callbacks) {
    const prevTime = prevProgress * totalDuration
    
    // Skip callbacks we haven't reached yet (only works for forward playback)
    if (prevTime < currentTime && callback.time > currentTime) {
        break // callbacks are sorted, no need to check further
    }
    
    if (prevTime < callback.time && currentTime >= callback.time) {
        callback.enter?.()
    } else if (prevTime >= callback.time && currentTime < callback.time) {
        callback.exit?.()
    }
}

Note: This optimization only works for forward playback; backward scrubbing still needs to check all callbacks.

4. Missing Documentation (Low Priority)

The PR adds a significant new feature but doesn't include:

  • JSDoc comments on the public SequenceCallback interface explaining behavior
  • Usage examples in code comments (the PR description has good examples though)
  • Edge case documentation (e.g., what happens with callbacks at time 0 or exactly at totalDuration)

Recommendation: Add JSDoc to packages/framer-motion/src/animation/sequence/types.ts:66-69:

/**
 * Callback to be invoked at a specific point in the sequence.
 * 
 * @example
 * animate([
 *   [element, { x: 100 }, { duration: 1 }],
 *   [{ enter: () => console.log('halfway!') }, { at: 0.5 }],
 *   [element, { y: 200 }, { duration: 1 }],
 * ])
 * 
 * @property enter - Called when timeline crosses this point moving forward (t_prev < time <= t_current)
 * @property exit - Called when timeline crosses this point moving backward (t_prev >= time > t_current)
 */
export interface SequenceCallback {
    enter?: VoidFunction
    exit?: VoidFunction
}

🔍 Code Quality Observations

  1. Consistent Style: Code follows repo conventions (named exports, arrow callbacks, strict equality).

  2. Good Separation of Concerns: Callback extraction is isolated in createAnimationsFromSequence, updater logic is in createCallbackUpdater, and integration is in animateSequence.

  3. Type Safety: The CallbackSegment type and union with Segment provide good type checking.


🎯 Overall Assessment

This is a well-designed feature that extends the animation sequence API thoughtfully. The implementation is clean and the tests are thorough. The main issues are:

  • Test timing mechanism should use proper frame loop
  • Type guard could be more defensive
  • Minor performance optimization opportunity for many callbacks

Recommendation: Merge after addressing the test timing issue (medium priority). The other issues are minor and can be addressed in follow-up PRs if needed.


Test Coverage: ✅ Excellent
Security Concerns: ✅ None identified
Performance: ✅ Good (minor optimization possible)
Code Quality: ✅ High
Breaking Changes: ✅ None

@mattgperry mattgperry merged commit e248b3e into main Feb 3, 2026
5 checks passed
@mattgperry mattgperry deleted the claude/low-overhead-animation-XwKRA branch February 3, 2026 12:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants